낙관적 락과 비관적 락을 선택하는 기준
낙관적 락과 비관적 락을 선택하는 기준
낙관적 락은 다른 트랜잭션을 미리 막지 않고 저장 시점에 충돌을 감지한다. 비관적 락은 읽는 순간부터 대상에 잠금을 걸어 변경 순서를 직렬화한다. 어느 쪽이 항상 우월한 것이 아니라 충돌 빈도, 트랜잭션 길이, 실패했을 때 다시 할 수 있는 일의 양을 함께 보고 선택해야 한다.
목차
- #문제가 되는 상황
- #마지막 저장이 앞선 변경을 덮어쓰는 과정
- #낙관적 락: 일단 진행하고 저장할 때 충돌을 확인한다
- #비관적 락: 먼저 잠그고 한 명씩 처리한다
- #두 방식을 비교하는 기준
- #재고 차감은 반드시 비관적 락이어야 할까
- #애플리케이션 구현에서 놓치기 쉬운 부분
- #운영 환경에서 확인할 지표
- #결론
- #관련 노트
문제가 되는 상황
관리자 두 명이 같은 상품 설명을 편집한다고 해 보자. 둘 다 version = 7인 문서를 읽었다. A는 제목을 수정하고, B는 본문을 수정했다. A가 먼저 저장해 version = 8이 되었지만 B의 애플리케이션이 처음 읽었던 객체 전체를 그대로 저장하면 A의 제목 변경이 사라질 수 있다.
10:00:00 A가 document(version=7)를 읽음
10:00:01 B가 document(version=7)를 읽음
10:00:10 A가 제목을 변경해 저장
10:00:15 B가 본문을 변경한 뒤 오래된 제목까지 함께 저장
이 문제를 Lost Update, 즉 갱신 분실이라고 부른다. 트랜잭션을 사용했다는 사실만으로는 방지되지 않는다. 두 요청이 각각 정상적으로 커밋될 수 있기 때문이다.
트랜잭션은 여러 작업을 하나의 단위로 묶는다. 하지만 여러 트랜잭션이 같은 데이터를 동시에 읽고 쓸 때 어떤 순서를 허용할지는 격리 수준과 잠금 전략이 결정한다.
마지막 저장이 앞선 변경을 덮어쓰는 과정
아래 코드는 전형적인 읽기-수정-쓰기 흐름이다. 예시는 특정 프로젝트의 코드를 옮긴 것이 아니라 동작을 설명하기 위해 단순화한 코드다.
type Document = {
id: number;
title: string;
body: string;
version: number;
};
async function updateBody(id: number, body: string) {
const document = await repository.findById(id);
document.body = body;
await repository.save(document);
}
save()가 다음처럼 기본 키만 조건으로 사용하면, 읽은 뒤 값이 바뀌었는지 알 수 없다.
UPDATE documents
SET title = :title,
body = :body
WHERE id = :id;
여기서 필요한 것은 “동시에 수정하지 못하게 막기” 하나만이 아니다. 다른 요청의 작업을 허용하되 저장 직전에 충돌을 발견하거나, 읽는 시점부터 다른 요청이 같은 데이터를 바꾸지 못하게 기다리게 할 수 있다. 각각 낙관적 락과 비관적 락에 해당한다.
낙관적 락: 일단 진행하고 저장할 때 충돌을 확인한다
낙관적 락은 이름과 달리 보통 DB의 물리적인 잠금을 오래 보유하지 않는다. “충돌이 흔하지 않을 것”이라고 가정하고 각 요청을 진행시킨 뒤, 저장 시점에 처음 읽은 버전이 그대로인지 확인한다.
CREATE TABLE documents (
id BIGINT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
updated_at DATETIME(6) NOT NULL
);
저장 쿼리에는 기본 키와 기존 버전을 함께 조건으로 넣는다.
UPDATE documents
SET title = :title,
body = :body,
version = version + 1,
updated_at = CURRENT_TIMESTAMP(6)
WHERE id = :id
AND version = :expected_version;
A가 version = 7 조건으로 먼저 저장하면 버전은 8이 된다. 뒤늦게 B가 같은 조건으로 실행한 UPDATE는 조건에 맞는 행을 찾지 못해 영향받은 행 수가 0이 된다. 이 0을 검사해야 낙관적 락이 완성된다.
class EditConflictError extends Error {}
async function saveDocument(input: Document) {
const affectedRows = await repository.updateIfVersionMatches({
id: input.id,
title: input.title,
body: input.body,
expectedVersion: input.version,
});
if (affectedRows === 0) {
throw new EditConflictError(
"다른 사용자가 먼저 문서를 수정했습니다. 최신 내용을 확인해 주세요.",
);
}
}
쿼리 실행 자체는 오류 없이 끝날 수 있다. WHERE id = ? AND version = ?에 맞는 행이 없어도 SQL 문법상 정상이다. ORM을 사용한다면 버전 불일치를 어떤 예외로 변환하는지 확인해야 한다.
충돌 후의 행동도 업무 성격에 따라 달라진다. 서버 내부 계산처럼 입력이 변하지 않는 작업은 최신 데이터를 다시 읽어 재시도할 수 있다. 하지만 사람이 쓴 문서 편집은 자동 재시도가 다른 사람의 변경을 다시 덮을 수 있다. 이 경우 최신 버전과 사용자의 수정본을 비교해 병합하게 해야 한다.
{
"code": "DOCUMENT_EDIT_CONFLICT",
"message": "다른 사용자가 먼저 수정했습니다.",
"latestVersion": 8
}
HTTP API라면 409 Conflict와 함께 최신 버전 정보를 반환할 수 있다. 핵심은 기술적인 충돌을 사용자가 이해할 수 있는 흐름으로 바꾸는 것이다.
비관적 락: 먼저 잠그고 한 명씩 처리한다
비관적 락은 충돌 가능성이 높거나, 작업을 상당 부분 수행한 뒤 실패시키는 비용이 클 때 유용하다. 트랜잭션 안에서 대상 행을 잠금 읽기하고, 그 잠금을 가진 상태에서 검증과 변경을 끝낸다.
START TRANSACTION;
SELECT stock
FROM products
WHERE id = :product_id
FOR UPDATE;
UPDATE products
SET stock = stock - :quantity
WHERE id = :product_id;
COMMIT;
첫 번째 트랜잭션이 행을 잠그면 같은 행을 FOR UPDATE로 읽거나 변경하려는 두 번째 트랜잭션은 기다린다. 첫 번째 트랜잭션이 커밋한 후 두 번째 요청은 새 재고를 기준으로 검증할 수 있다.
sequenceDiagram
participant A as 주문 A
participant DB as Database
participant B as 주문 B
A->>DB: SELECT ... FOR UPDATE
DB-->>A: stock=1, row lock 획득
B->>DB: SELECT ... FOR UPDATE
Note over B,DB: A의 트랜잭션 종료까지 대기
A->>DB: UPDATE stock=0
A->>DB: COMMIT
DB-->>B: stock=0 반환
B-->>B: 품절로 처리비관적 락은 이해하기 쉽지만 공짜가 아니다. 잠금을 보유하는 시간이 길어지면 대기 요청이 늘고 처리량이 떨어진다. 서로 다른 순서로 여러 행을 잠그면 데드락 가능성도 생긴다.
결제 승인, 파일 업로드, 이메일 전송처럼 응답 시간을 통제할 수 없는 I/O를 트랜잭션 안에서 호출하면 잠금 시간이 네트워크 지연만큼 늘어난다. DB 트랜잭션은 가능한 한 DB 작업만 포함하고 짧게 끝내야 한다.
두 방식을 비교하는 기준
| 기준 | 낙관적 락 | 비관적 락 |
|---|---|---|
| 충돌을 다루는 시점 | 저장할 때 감지 | 읽을 때부터 방지 |
| 대기 방식 | 서로 막지 않고 진행 | 경쟁 트랜잭션이 잠금 해제를 기다림 |
| 충돌이 드물 때 | 불필요한 대기가 적어 유리 | 잠금 비용이 상대적으로 큼 |
| 충돌이 잦을 때 | 실패와 재시도가 반복될 수 있음 | 처리 순서를 직렬화하기 쉬움 |
| 긴 사용자 편집 | 적합 | 잠금을 오래 보유할 수 있어 부적합 |
| 실패 후 복구 | 최신 값 재조회, 병합 또는 재시도 | 잠금을 얻은 뒤 최신 값으로 처리 |
| 대표 구현 | version 조건 UPDATE |
SELECT ... FOR UPDATE |
선택할 때는 “충돌이 몇 번 발생하는가”만 보면 부족하다.
- 충돌 전까지 수행한 작업을 버리고 다시 해도 되는가?
- 사용자가 직접 입력한 내용을 자동 재시도해도 의미가 보존되는가?
- 한 행을 잠근 채 수행할 로직을 수십 밀리초 안에 끝낼 수 있는가?
- 요청이 몰릴 때 기다리게 하는 편과 빠르게 실패시키는 편 중 어떤 경험이 나은가?
- 여러 행을 잠근다면 잠금 순서를 통일할 수 있는가?
재고 차감은 반드시 비관적 락이어야 할까
재고 차감 예제에는 FOR UPDATE가 자주 등장하지만, 단순한 수량 차감이라면 조건부 UPDATE 한 문장으로도 원자성을 확보할 수 있다.
UPDATE products
SET stock = stock - :quantity
WHERE id = :product_id
AND stock >= :quantity;
영향받은 행이 1개면 성공, 0개면 존재하지 않는 상품이거나 재고 부족이다. 애플리케이션이 재고 값을 먼저 읽고 계산하지 않으므로 읽기와 쓰기 사이의 경쟁 구간이 사라진다.
const changed = await productRepository.decreaseStock({
productId,
quantity,
});
if (changed === 0) {
throw new OutOfStockError();
}
반면 등급별 구매 제한, 예약 상태, 여러 행의 합계처럼 복잡한 조건을 함께 확인해야 한다면 잠금 읽기가 더 명확할 수 있다. 따라서 도메인 이름만 보고 락을 고르기보다 한 SQL 문으로 불변식을 표현할 수 있는지부터 확인하는 편이 좋다.
애플리케이션 구현에서 놓치기 쉬운 부분
버전 값은 클라이언트와 왕복해야 한다
웹 편집 화면에서 낙관적 락을 쓴다면 사용자가 읽었던 버전을 수정 요청에 포함해야 한다. 서버가 UPDATE 직전에 현재 버전을 다시 조회해서 넣으면 항상 최신 버전이므로 충돌 검사가 무의미해진다.
PATCH /documents/42
Content-Type: application/json
{
"title": "동시성 제어 정리",
"body": "...",
"version": 7
}
재시도는 트랜잭션 전체를 대상으로 한다
교착 상태나 일시적인 잠금 오류가 발생했을 때 실패한 SQL 한 줄만 다시 실행해서는 안 된다. DB가 트랜잭션 전체를 롤백했을 수 있고, 앞서 읽은 값도 더 이상 최신이 아닐 수 있다. 새로운 트랜잭션에서 읽기부터 다시 해야 한다.
잠금 대상은 인덱스와 실행 계획에 영향을 받는다
비관적 락의 범위가 항상 “눈에 보이는 한 행”인 것은 아니다. 조건에 맞는 인덱스가 없으면 더 넓은 범위를 탐색하고 잠글 수 있다. 범위 조건과 존재하지 않는 키를 조회할 때는 격리 수준에 따라 gap lock까지 고려해야 한다. 자세한 내용은 SELECT FOR UPDATE를 사용할 때 주의할 점에서 이어서 정리한다.
운영 환경에서 확인할 지표
전략을 선택한 뒤에는 가정이 맞는지 숫자로 확인해야 한다.
| 지표 | 의미 | 확인할 신호 |
|---|---|---|
| 낙관적 락 충돌률 | 전체 저장 중 버전 불일치 비율 | 특정 문서나 시간대에 집중되는가 |
| 평균 재시도 횟수 | 성공까지 다시 수행한 횟수 | 재시도가 부하를 증폭하는가 |
| lock wait time | 잠금 획득까지 기다린 시간 | p95, p99가 급격히 늘어나는가 |
| deadlock count | DB가 중단한 교착 상태 수 | 동일한 쿼리 조합이 반복되는가 |
| transaction duration | 트랜잭션 시작부터 종료까지 시간 | 외부 I/O가 포함됐는가 |
테스트도 단일 요청 성공 여부만 확인해서는 부족하다. 같은 버전을 가진 요청 20개를 동시에 보내고 정확히 한 요청만 성공하는지 확인해야 한다.
const attempts = Array.from({ length: 20 }, () =>
editDocument({ id: 42, expectedVersion: 7 }),
);
const results = await Promise.allSettled(attempts);
const successes = results.filter((result) => result.status === "fulfilled");
expect(successes).toHaveLength(1);
결론
낙관적 락과 비관적 락의 차이는 충돌을 감지하느냐 막느냐, 그리고 그 시점이 언제냐에 있다. 긴 사용자 편집처럼 잠금을 유지할 수 없고 충돌이 드문 작업은 버전 기반 낙관적 락이 자연스럽다. 짧은 트랜잭션에서 충돌이 잦고 최신 상태를 확인한 뒤 여러 조건을 변경해야 한다면 비관적 락이 이해하기 쉽다.
다만 재고 차감처럼 불변식을 조건부 UPDATE로 표현할 수 있다면 둘 중 하나를 고르기 전에 원자적 SQL로 문제를 더 작게 만들 수 있다. 최종 선택은 이름이나 관습이 아니라 충돌률, 잠금 대기, 실패 비용을 측정한 결과로 내려야 한다.